fix(sdk): bound AsyncExecutor.close() so it cannot hang forever - #4548
Conversation
|
📁 PR Artifacts Notice This PR contains a |
e2629f7 to
4299def
Compare
|
@/tmp/pr-evidence-revised.md |
The repro script (.pr/repro-async-executor-close-hang.py) was accidentally committed from a separate debugging session (references PR #4548/issue #4546, not this PR's issue #4514). It fails pre-commit (import ordering, ARG001 unused arg) and has hardcoded developer paths and credential references. Deleting it resolves both the CI lint failures and the 3 review threads. Co-authored-by: openhands <openhands@all-hands.dev>
neubig
left a comment
There was a problem hiding this comment.
Requesting changes: this is a mitigation, not a fundamental fix. Bounding AsyncExecutor.close() to 30s replaces "hang forever" with "block all conversations for 30s". That's a shorter wedge, but still a wedge — and serial 30s stalls accumulate under load.
The root cause is that close() runs under the global _lifecycle_lock (in delete_conversation), so even a bounded hang blocks every other conversation. See the ready-for-dev issue #4569 for the fundamental solution (per-conversation locks).
This PR can likely be closed as superseded once #4569 is implemented — if close() can only block its own conversation, a timeout becomes a safety net rather than a critical fix.
neubig
left a comment
There was a problem hiding this comment.
Requesting changes: this is a mitigation, not a fundamental fix. Bounding AsyncExecutor.close() to 30s replaces "hang forever" with "block all conversations for 30s". That's a shorter wedge, but still a wedge — and serial 30s stalls accumulate under load.
The root cause is that close() runs under the global _lifecycle_lock (in delete_conversation), so even a bounded hang blocks every other conversation. See the ready-for-dev issue #4569 for the fundamental solution (per-conversation locks).
This PR can likely be closed as superseded once #4569 is implemented — if close() can only block its own conversation, a timeout becomes a safety net rather than a critical fix.
neubig
left a comment
There was a problem hiding this comment.
Requesting changes: this is a mitigation, not a fundamental fix. Bounding AsyncExecutor.close() to 30s replaces "hang forever" with "block all conversations for 30s". That's a shorter wedge, but still a wedge — and serial 30s stalls accumulate under load.
The root cause is that close() runs under the global _lifecycle_lock (in delete_conversation), so even a bounded hang blocks every other conversation. See the ready-for-dev issue #4569 for the fundamental solution (per-conversation locks).
This PR can likely be closed as superseded once #4569 is implemented — if close() can only block its own conversation, a timeout becomes a safety net rather than a critical fix.
|
Requesting changes: this is a mitigation, not a fundamental fix. Bounding The root cause is that This PR can likely be closed as superseded once #4569 is implemented — if |
neubig
left a comment
There was a problem hiding this comment.
Thanks for the thorough reproduction and for addressing the unbounded shutdown path. The direction is useful as a last-resort containment mechanism, especially now that per-conversation lifecycle locks limit the blast radius. Before merging, please address these safety requirements:
-
Document the semantics precisely. This is bounded, best-effort shutdown—not guaranteed cleanup. After the timeout, the portal/helper/worker threads and their resources may still be alive. Please make that explicit in the API docstring, warning, and PR description.
-
Make abandonment observable. The timeout warning should clearly state that shutdown was abandoned and resources may remain active. Include enough context to identify the executor/owner, the timeout, and that cancellation was attempted.
-
Preserve useful failure information. Avoid reducing shutdown failures to only
str(exception). Log the exception with traceback/context where appropriate, while keeping teardown non-raising. Broad exception handling is acceptable for destructor-safe cleanup only if the failure remains diagnosable. -
Keep production shutdown bounded. Audit callers and confirm that no production path passes
timeout=None; do not reintroduce the original unbounded behavior through the compatibility option. -
Reconsider the default timeout. Thirty seconds is inherited from the browser cleanup timeout, but it may be too long for lifecycle teardown. Please justify the value with normal shutdown measurements or choose a shorter shutdown-specific default.
-
Add regression coverage. Tests should cover cancellation of cancellable tasks, timeout/abandonment of uncancellable synchronous work, idempotent close, never-started portals, observable timeout diagnostics, and shutdown exceptions. The timeout-path test should explicitly document that background threads may remain.
-
Track the remaining limitation. Please link or note #4598: arbitrary synchronous code cannot be forcibly interrupted from Python threads, so this change contains the caller-side wedge but does not eliminate possible zombie threads.
This review was created by an AI agent (OpenHands) on behalf of the user.
5787054 to
80a36f1
Compare
|
🚦 CI is currently failing on this PR's latest commit. Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request This is an automated check - no AI was used to generate this comment. |
80a36f1 to
a1e3900
Compare
close() passed no exception to the portal context manager, so anyio took its graceful path -- portal.stop(cancel_remaining=False) -- and waited for in-flight tasks to finish on their own. It then joined the portal thread with no timeout. Either half can block the caller indefinitely. That matters because LocalConversation.close() releases tool executors in an unbounded loop, so one stuck portal task wedges conversation shutdown and every later operation that needs the conversation lock. Cancel remaining tasks on shutdown, and bound the wait for the portal thread. The portal thread is a daemon, so abandoning it with a warning is safe when it is stuck on work that ignores cancellation. Closes OpenHands#4546 Co-authored-by: openhands <openhands@all-hands.dev>
a1e3900 to
9dad0e2
Compare
9dad0e2 to
102e862
Compare
neubig
left a comment
There was a problem hiding this comment.
Approving: the rebased commits address the safety review items. Bounded best-effort semantics are documented; abandonment is observable (owner+timeout+cancellation-attempted) and the helper thread is named for traceability; teardown failures log with exc_info; all production callers use the 10s default (none pass timeout=None); #4598 is linked for the un-interruptible-thread limitation. #4570 (per-conversation locks) is merged, so a stuck close() now only blocks its own conversation — this is a safety net, not the load-bearing fix. CI green.
HUMAN:
Okay, so this issue hit me while I was running this on a self-hosted server. Everything looked healthy. I got two 200s back, but every attempt to open the conversation just timed out and it was stuck for like eight hours, until I finally decided to ask Claude to dig around and figure it out.
AGENT:
Why
AsyncExecutor.close()could block forever, for two independent reasons:close()calledportal_cm.__exit__(None, None, None). Passing no exception makes anyio take its graceful path,portal.stop(cancel_remaining=False), which waits for in-flight tasks to finish on their own — so a task that never completes blocks shutdown permanently, even one that would die instantly if cancelled.start_blocking_portal()ends in a barethread.join(). So even with cancellation, a task that cannot be cancelled — anything awaiting inside a worker thread, where anyio cannot deliver cancellation until the thread returns — hangs the join forever.This is reachable in production.
LocalConversation.close()releases tool executors in a loop with no timeout, so one stuck portal task wedges conversation shutdown while holding the conversation lock. On an agent-server running the browser tool, that left everyGET /api/conversations/{id}/events*blocked for 8h21m while/healthand the metadata routes kept answering normally — the service looked healthy but no conversation could be opened, and only a process restart recovered it.AsyncExecutoralso backsMCPClientandACPAgent, so the exposure is not browser-specific.Summary
AsyncExecutor.close()now stops the portal withcancel_remaining=Trueinstead of waiting for in-flight tasks.timeoutargument (defaultDEFAULT_CLOSE_TIMEOUT = 10.0;Nonekeeps the old blocking behaviour). On expiry it logs a warning and abandons the thread, which is safe because anyio creates it as a daemon.tests/sdk/utils/test_async_executor.pycovering both hang modes plus idempotency and the never-started-portal path.Behaviour is additive: the new argument is optional and the normal close path is unchanged.
Safety review (addressed)
This PR is a bounded, best-effort safety net — not guaranteed cleanup. After the timeout, the portal/helper/worker threads and their resources may still be alive. This is a deliberate trade-off (blocking the caller forever is worse); the semantics are documented as such in the
close()docstring and the abandonment warning.close()docstring states it is best-effort, that threads/resources may survive the timeout, and that the path is non-raising + idempotent.type(self).__qualname__), the timeout, and that cancellation was already attempted, so it can be correlated withpy-spy/the wedged resource. The helper thread is named<owner>-closefor traceability.exc_info=True(full traceback) rather thanstr(exc), while staying non-raising for destructor-safety.BrowserToolExecutor,ACPAgent,MCPClient) callclose()with no args → the 10s default. None passtimeout=None;Noneis retained only for backward compatibility and is documented as "do not use on a production path."close()can only block its own conversation, not all of them — this PR is now a safety net rather than the load-bearing fix.Issue Number
Closes #4546
How to Test
Reproduce the hang on
main(25 lines, no browser or network needed):On
mainthis printsclose() STILL BLOCKED after 10s; on this branch it printsclose() returned.Then run the new tests:
They are real regression tests — reverting
async_executor.pywhile keeping the test file makestest_close_returns_with_task_still_runningfail.Type